昨天我們成功把 23 個語法 Chunks 轉換成 1024 維向量並存入 SQLite。
但如果你在程式碼庫裡只押注向量檢索,很快會踢到鐵板:
當使用者搜尋精確名稱(例如 COLLECTION_NAME 或某個特定錯誤代碼)時,Embedding 模型往往會把它當成一般單詞,算出來的相似度常常輸給其他語意豐富但毫無關聯的長段落。
反過來,當使用者問「Qdrant 本機路徑在哪裡設定?」時,程式碼裡可能寫的是 QDRANT_LOCAL_DIR 或直接宣告在檔案最上層,純文字比對若沒對齊字元就會交白卷。
今天我們要實作 Hybrid Search(混合檢索),不再強迫自己在關鍵字(Lexical)與向量(Semantic)之間二選一,而是利用工業界標準的 Reciprocal Rank Fusion(RRF) 演算法將兩邊結果融為一體!
合併分數最直覺的做法是「加權相加」(例如 $0.5 \times \text{BM25} + 0.5 \times \text{Cosine}$)。但文字搜尋的分數(TF-IDF / 命中次數)與向量的餘弦相似度(-1 到 1)量綱完全不同,正規化門檻極高,稍微換個查詢詞權重就跑掉了。
RRF 不看絕對分數,只看名次(Rank):
$$RRF_Score(d) = \sum_{m \in M} \frac{1}{k + r_m(d)}$$
只要某個檔案在兩邊都有上榜,它的 RRF 分數就會顯著超越只在單邊露臉的結果;若在精確文字拿到第 1 名,也能保有足夠高的競爭力。
app/hybrid_search.py我們將文字比對(Day 7)與向量比對(Day 12)整合進混合檢索模組:
# app/hybrid_search.py
from typing import List, Dict, Any
from app.codebase import CodebaseAnalyzer, VectorIndexer
from app.embeddings import EmbeddingProvider
class HybridSearchEngine:
def __init__(self, analyzer: CodebaseAnalyzer, vector_indexer: VectorIndexer, embed_provider: EmbeddingProvider):
self.analyzer = analyzer
self.vector_indexer = vector_indexer
self.embed_provider = embed_provider
def search(self, query: str, limit: int = 5, rrf_k: int = 60) -> List[Dict[str, Any]]:
# 1. 取得 Lexical 候選 (關鍵字比對)
lexical_candidates = self._lexical_search(query, limit=10)
# 2. 取得 Semantic 候選 (向量餘弦比對)
query_vec = self.embed_provider.embed_text(query)
semantic_candidates = self.vector_indexer.search_semantic(query_vec, limit=10)
# 3. 執行 Reciprocal Rank Fusion (RRF)
scores: Dict[str, float] = {}
candidate_map: Dict[str, Dict[str, Any]] = {}
# 計入 Lexical 排名權重
for rank, item in enumerate(lexical_candidates, start=1):
key = f"{item['path']}:{item['start_line']}"
candidate_map[key] = item
scores[key] = scores.get(key, 0.0) + (1.0 / (rrf_k + rank))
# 計入 Semantic 排名權重
for rank, item in enumerate(semantic_candidates, start=1):
key = f"{item['path']}:{item['start_line']}"
if key not in candidate_map:
candidate_map[key] = item
scores[key] = scores.get(key, 0.0) + (1.0 / (rrf_k + rank))
# 4. 排序並輸出標準 Evidence
sorted_keys = sorted(scores.keys(), key=lambda k: scores[k], reverse=True)
results = []
for k in sorted_keys[:limit]:
item = candidate_map[k]
item["rrf_score"] = scores[k]
results.append(item)
return results
def _lexical_search(self, query: str, limit: int) -> List[Dict[str, Any]]:
"""確定性文字逐行比對"""
results = []
files = self.analyzer.discover_python_files()
for rel_path in files:
safe_path = self.analyzer.resolve_safe_path(str(rel_path))
try:
lines = safe_path.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
continue
for idx, line in enumerate(lines, start=1):
if query.lower() in line.lower():
results.append({
"path": str(rel_path).replace("\\", "/"),
"start_line": idx,
"end_line": idx,
"kind": "lexical_match",
"name": query,
"content": line.strip()
})
if len(results) >= limit:
return results
return results
tests/unit/test_hybrid_search.py在不依賴任何外部模型的環境下,使用 HashEmbeddingProvider 驗證 RRF 融合邏輯是否正確去重與加權:
# tests/unit/test_hybrid_search.py
import sqlite3
from app.codebase import CodebaseAnalyzer, VectorIndexer
from app.embeddings import HashEmbeddingProvider
from app.hybrid_search import HybridSearchEngine
def test_rrf_hybrid_fusion(tmp_path):
repo = tmp_path / "repo"
repo.mkdir()
src = repo / "src"
src.mkdir()
# 建立測試檔案
sample_file = src / "search_target.py"
sample_file.write_text("QDRANT_STORAGE_PATH = '/local/path'\n", encoding="utf-8")
analyzer = CodebaseAnalyzer(str(repo))
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
vector_indexer = VectorIndexer(conn)
provider = HashEmbeddingProvider(dim=1024)
# 模擬向量寫入
vector_indexer.index_chunk(
"src/search_target.py", "constant", "QDRANT_STORAGE_PATH", 1, 1,
"QDRANT_STORAGE_PATH = '/local/path'", provider.embed_text("Qdrant storage path")
)
engine = HybridSearchEngine(analyzer, vector_indexer, provider)
results = engine.search("QDRANT_STORAGE_PATH", limit=1)
assert len(results) == 1
assert results[0]["path"] == "src/search_target.py"
assert results[0]["start_line"] == 1
assert "rrf_score" in results[0]
執行測試確認通過:
uv run pytest tests/unit/test_hybrid_search.py -v
tests/unit/test_hybrid_search.py::test_rrf_hybrid_fusion PASSED [100%]
============================== 1 passed in 0.05s ==============================
mobileai-local-rag在 CLI 串接 hybrid-search 指令後,我們以自然語言查詢「Qdrant path」:
uv run python -m app.cli hybrid-search "Qdrant path"
輸出結果展現了混合檢索的威力,同時網羅了精確引用與語意片段:
Top Evidence Results for "Qdrant path":
1. [Lexical] src/build_index.py:2
import: from qdrant_client import QdrantClient (RRF: 0.0322)
2. [Semantic File] src/build_index.py:1-60
file: 向量索引建立管線與資料庫初始化 (RRF: 0.0318)
3. [Semantic Func] src/rag_chat.py:25-45
function: init_qdrant_session() 本機路徑設定與客戶端連線 (RRF: 0.0161)
文字搜尋抓到了第 2 行具體的 QdrantClient import。
向量搜尋抓到了 rag_chat.py 裡雖然沒有「path」這個字,但實作中帶有本機路徑初始化行為的整個函式區塊。
透過 RRF 排序後,最重要的入口檔案 build_index.py 被穩穩推到最前面。
今天我們成功把檢索系統升級為雙引擎驅動:
精準與模糊兼顧:識別符號走關鍵字,意圖探索走向量。
無痛融合:使用 RRF 避免跨維度分數校準的痛點,排序透明穩定。
然而,混合檢索雖然召回了高質量的候選集,但列表中依然混雜了頂層的整檔 Chunk、單行 Import 與核心函式定義。送給模型前如果沒有針對「定義優先權」進行最後過濾,依然可能讓 LLM 分心。
明天在 Day 14(第二週收尾篇)中,我們將實作 Deterministic Reranking(確定性重排),讓真正的 Symbol 定義與精準名稱無條件排在前面,完成第二週的完整檢索管線!